UUID v4와 시간 정렬 UUID 비교
UUID v4와 시간 정렬 UUID 비교
보안상 예측 불가능성, 정렬 가능성, DB 저장 형식, 라이브러리 지원을 함께 본다. 문자열 36자보다 16바이트 바이너리 저장이 공간에 유리하다.
목차
- #문제가 되는 상황
- #UUID v4는 무작위 식별자다
- #UUID v7은 시간순 배치를 목표로 한다
- #B+Tree 인덱스에서 삽입 패턴이 달라진다
- #시간 정렬은 완전한 생성 순서를 뜻하지 않는다
- #문자열과 16바이트 저장 비교
- #byte order와 라이브러리를 통일한다
- #UUID를 보안 권한으로 사용하지 않는다
- #created_at 컬럼은 여전히 필요하다
- #선택과 측정 기준
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
여러 애플리케이션 서버에서 DB insert 전에 ID를 만들려면 UUID v4가 편리하다. 중앙 sequence 없이도 충돌 가능성이 매우 낮은 값을 생성할 수 있다. 다만 새 값이 무작위 위치에 분포하므로 B+Tree primary index의 여러 페이지에 삽입될 수 있다.
UUID v7은 가장 앞부분에 Unix millisecond timestamp를 배치해 시간에 따라 값이 대략 증가하도록 만든 표준이다. 새 키가 비슷한 index 영역에 모여 v4보다 locality를 높일 수 있다. 그렇다고 모든 DB에서 무조건 더 빠르거나, ID 하나로 정확한 업무 생성 순서와 보안을 해결하는 것은 아니다.
UUID 문자열, 테이블, benchmark 항목은 비교를 위한 가상 예제다. UUID 생성은 직접 구현하지 않고 RFC 9562를 따르는 검증된 라이브러리를 사용한다.
UUID v4는 무작위 식별자다
UUID는 128비트 형식이고 v4는 version과 variant 비트를 제외한 대부분을 random 또는 pseudorandom 값으로 채운다.
f47ac10b-58cc-4372-a567-0e02b2c3d479
^
version 4
애플리케이션에서 생성할 수 있으므로 다음 흐름이 가능하다.
const eventId = crypto.randomUUID();
await outboxRepository.insert({
id: eventId,
type: "document-created",
payload: { documentId },
});
publishAfterCommit(eventId);
DB insert 전에 ID를 로그와 event correlation에 사용할 수 있고 여러 node가 별도 worker 번호 없이 만든다. 생성기는 충분한 entropy를 가진 CSPRNG를 사용해야 하며 DB에는 primary key 또는 unique constraint를 둔다.
v4는 생성 시간과 정렬 관계가 없다. ORDER BY id 결과를 최신순이라고 해석하면 안 된다.
UUID v7은 시간순 배치를 목표로 한다
UUID v7은 가장 앞의 48비트에 Unix epoch 기준 millisecond timestamp를 big-endian으로 넣는다. version·variant를 제외한 나머지 74비트는 random 또는 표준이 허용하는 monotonicity 보조 구조로 채운다.
| 48-bit unix_ts_ms | ver=7 | rand_a | var | rand_b |
0198f21a-7c42-7b2e-a9d1-5c80c4e2a901
^
version 7
timestamp가 상위 비트에 있으므로 raw byte 순서 비교에서 최근에 생성한 값이 대체로 뒤에 온다. v1처럼 MAC address를 넣지 않고 널리 쓰이는 Unix timestamp를 사용한다.
import { uuidv7 } from "a-maintained-uuid-library";
const document = {
id: uuidv7(),
title: "시간 정렬 UUID 실험",
};
위 패키지 이름은 특정 구현을 권장하는 실제 import가 아니라 예시다. 사용하는 언어의 라이브러리가 RFC 9562 UUIDv7을 구현하는지, 같은 millisecond 내 monotonicity와 clock rollback을 어떻게 처리하는지 확인한다.
B+Tree 인덱스에서 삽입 패턴이 달라진다
B+Tree 계열 index는 정렬된 key 공간을 page 단위로 관리한다. Auto Increment나 UUIDv7처럼 증가 경향이 있는 key는 최근 영역에 삽입되는 경우가 많다. UUIDv4는 key 공간 전체의 무작위 page에 삽입된다.
flowchart TD
V4[UUID v4 새 값] --> P1[왼쪽 page]
V4 --> P2[중간 page]
V4 --> P3[오른쪽 page]
V7[UUID v7 새 값] --> H[최근 timestamp 근처 page]무작위 삽입은 cache locality 저하, page split, write amplification에 영향을 줄 수 있다. 그러나 실제 차이는 다음 조건에 따라 달라진다.
- DB 저장 엔진과 primary/clustered index 구조
- buffer pool이 working set을 수용하는지
- 동시 writer 수와 insert rate
- key fill factor와 page 크기
- secondary index 개수와 key 폭
- UUID를 text로 저장하는지 binary로 저장하는지
작은 테이블이나 read-heavy 서비스에서는 차이가 의미 없을 수 있다. 실제 데이터량과 동시성으로 benchmark하지 않고 v7 도입만으로 성능 향상을 단정하지 않는다.
시간 정렬은 완전한 생성 순서를 뜻하지 않는다
UUIDv7의 기본 timestamp 정밀도는 millisecond다. 같은 millisecond에 여러 node가 ID를 만들면 random 부분에 따라 순서가 정해질 수 있고 실제 호출 순서와 다를 수 있다.
Node A clock: 10:00:00.123 → UUID A
Node B clock: 10:00:00.122 → UUID B
실제 DB 도착: A 다음 B
UUID 정렬: B 다음 A일 수 있음
clock skew와 clock rollback도 있다. 라이브러리는 같은 tick 안의 counter 또는 monotonic random 방식으로 한 node의 순서를 개선할 수 있지만, 독립 node 전체의 인과 순서를 자동으로 보장하지 않는다.
다음 요구를 UUID 정렬에 맡기지 않는다.
- 결제 event의 업무 순서
- message broker offset
- 법적 문서의 연속 번호
- 마지막 write 우선 충돌 해결
- 정확한 사용자 생성 시각
이런 요구에는 DB transaction sequence, event version, logical clock, 명시적인 timestamp와 tie-breaker가 필요하다.
문자열과 16바이트 저장 비교
UUID의 canonical text는 하이픈을 포함해 36자지만 본질은 16바이트다.
CREATE TABLE events_text (
id CHAR(36) PRIMARY KEY,
created_at DATETIME(3) NOT NULL
);
CREATE TABLE events_binary (
id BINARY(16) PRIMARY KEY,
created_at DATETIME(3) NOT NULL
);
| 저장 형식 | 크기 | 장점 | 단점 |
|---|---|---|---|
CHAR(36) |
문자 집합 설정에 따라 큼 | SQL에서 사람이 읽기 쉬움 | index·외래 키가 커짐 |
| native UUID type | DB 정의 | 함수·표현 지원 가능 | DB 종속성 |
BINARY(16) |
16바이트 | 작고 byte 비교 가능 | 변환 함수·도구 필요 |
문자열을 사용한다면 collation이 case-insensitive 비교와 불필요한 locale 규칙을 적용하지 않는지 확인한다. binary 저장에서는 API 경계에서 canonical text와 변환하는 repository 함수를 한 곳에 둔다.
type UuidBytes = Buffer;
function toDatabaseUuid(canonical: string): UuidBytes {
assertCanonicalUuid(canonical);
return Buffer.from(canonical.replaceAll("-", ""), "hex");
}
function fromDatabaseUuid(bytes: UuidBytes): string {
return formatCanonicalUuid(bytes);
}
byte order와 라이브러리를 통일한다
일부 과거 최적화는 UUID field의 byte 순서를 재배치해 index locality를 높였다. 서비스 A는 canonical byte order, 서비스 B는 vendor-specific swap order로 저장하면 같은 UUID가 DB에서 다른 binary가 된다.
UUIDv7은 raw byte 순서 자체가 시간 정렬을 고려해 정의되어 있으므로 임의 swap을 추가하지 않는다. 다음 계약을 시스템 전체에 고정한다.
- 사용하는 UUID version
- canonical text parse 규칙
- DB binary byte order
- API와 event에서 text로 전달할지
- 생성 library와 지원 version
- 잘못된 variant/version의 거부 여부
function assertDocumentId(id: string) {
const parsed = parseUuid(id);
if (parsed.version !== 7) {
throw new Error("DOCUMENT_ID_MUST_BE_UUID_V7");
}
}
기존 v4 데이터와 v7을 함께 허용하는 migration 기간이라면 version 강제 대신 두 버전을 명시적으로 허용한다.
UUID를 보안 권한으로 사용하지 않는다
UUIDv4는 random source가 안전하면 추측하기 어렵지만 UUID가 노출되는 경로는 많다. URL 공유, browser history, referrer, 로그, analytics, screenshot으로 유출될 수 있다. UUIDv7은 timestamp도 포함하므로 생성 시간대와 순서를 일부 드러낸다.
const document = await repository.findById(request.params.documentId);
if (!document || !canRead(request.user, document)) {
throw new NotFoundError();
}
UUID의 소지만으로 접근을 허용하는 capability URL이 필요하다면 별도의 충분히 random한 secret token, 짧은 만료, scope, 폐기 기능을 설계한다. 리소스 ID와 접근 secret을 같은 값으로 쓰지 않는 편이 회수와 감사에 유리하다.
created_at 컬럼은 여전히 필요하다
UUIDv7에서 timestamp를 추출할 수 있어도 명시적인 업무 생성 시각을 대체하지 않는다.
CREATE TABLE events (
id BINARY(16) NOT NULL,
occurred_at DATETIME(6) NOT NULL,
received_at DATETIME(6) NOT NULL,
payload JSON NOT NULL,
PRIMARY KEY (id),
INDEX idx_events_occurred_id (occurred_at, id)
);
ID 생성 시각, event 발생 시각, 서버 수신 시각은 서로 다를 수 있다. backfill·import에서는 오늘 만든 UUID가 과거 데이터를 나타낼 수도 있다. 조회 의미에 맞는 timestamp 컬럼과 timezone·precision을 별도로 저장한다.
페이지네이션에서도 ORDER BY created_at, id처럼 명시적인 정렬과 tie-breaker를 사용하면 ID 생성 정책이 바뀌어도 API 계약을 유지하기 쉽다.
선택과 측정 기준
| 기준 | UUID v4 | UUID v7 |
|---|---|---|
| 중앙 조정 없는 생성 | O | O |
| 생성 시간 순서 경향 | X | O |
| 시간 정보 노출 | X | O |
| B+Tree locality | 낮을 수 있음 | 높을 수 있음 |
| 같은 ms 완전 순서 | 해당 없음 | 구현에 따라 보조, 전역 보장 아님 |
| 표준 크기 | 16바이트 | 16바이트 |
benchmark는 최소한 다음을 비교한다.
insert throughput
p50/p95/p99 insert latency
index size
page split 또는 storage write 지표
buffer cache hit rate
secondary index와 foreign key 크기
replication lag
ID만 바꾸고 test data가 작거나 buffer가 모두 memory에 있으면 실제 운영 차이를 재현하지 못할 수 있다. 예상 데이터량과 동시 writer로 장시간 측정한다.
실전 점검 목록
- 중앙 조정 없는 생성과 시간 locality가 실제 요구인가?
- UUIDv7 구현이 RFC 9562와 clock rollback을 지원하는가?
- 같은 millisecond·여러 node의 순서를 과신하지 않는가?
- 16바이트 저장과 byte order가 모든 서비스에서 같은가?
- UUID를 authorization이나 secret으로 사용하지 않는가?
- 업무 시각을 별도 timestamp 컬럼에 저장하는가?
- 실제 DB 엔진과 운영 크기로 v4/v7을 benchmark했는가?
보안상 예측 불가능성, 정렬 가능성, DB 저장 형식, 라이브러리 지원을 함께 본다. 문자열 36자보다 16바이트 바이너리 저장이 공간에 유리하다.
결론
UUIDv4는 random 기반 분산 식별자이고 UUIDv7은 상위 48비트의 Unix millisecond timestamp로 대략적인 시간 정렬과 index locality를 목표로 한다. v7도 여러 node의 정확한 생성 순서나 권한을 보장하지 않으며 명시적인 created_at을 대체하지 않는다. UUID는 가능한 16바이트로 저장하고 byte order·library·version을 통일한 뒤 실제 DB workload로 index 크기와 latency를 측정해 선택해야 한다.